package nl.altenpts.net.kad;

import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.OutputStream;
import java.io.StreamCorruptedException;
import java.net.InetSocketAddress;
import java.security.InvalidKeyException;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.util.Arrays;
import java.util.List;
import java.util.Random;

import nl.altenpts.net.NetReactor;
import nl.altenpts.net.broker.IncomingMessage;
import nl.altenpts.net.broker.RequestBroker;
import nl.altenpts.net.broker.ResponseHandler;
import nl.altenpts.net.broker.SignedMessageBroker;


public class Kademlia {
	/**
	 * Kedemlia, peer-to-peer Information system based on XOR metric
	 * 
	 * @param reactor
	 * @param keys
	 * @param address
	 * @throws InvalidKeyException
	 * @throws NoSuchAlgorithmException
	 * @throws IOException
	 */
	public Kademlia(NetReactor reactor, KeyPair keys, InetSocketAddress address) throws InvalidKeyException, NoSuchAlgorithmException, IOException {
		this.reactor = reactor;
		
		if(keys == null) {
			generateKeys();			
		} else {
			mykeys = keys;
		}

		mynode = new Node(mykeys.getPublic(), address);
		
		table = new RoutingTable(mynode);
	
		store = new ValueStore();
		
		SignedMessageBroker msgbroker = new SignedMessageBroker(reactor, mykeys, address, signalg);
		RequestBroker reqbroker = new RequestBroker(msgbroker, reactor);
		kbroker = new KBroker(reqbroker, table, store);
		
		bucketcheckinterval = (freshbucket / mynode.getId().size());
		bucketfreshness = new long[mynode.getId().size()];
		for(int i = 0; i < mynode.getId().size(); i++) {
			bucketfreshness[i] = 0;
		}
		System.out.println(mynode.getAddress().getPort() + ": " + Arrays.toString(mynode.getId().getByteArray()));
	}
	
	public int getPort() {
		return mynode.getAddress().getPort();
	}

	public InetSocketAddress getAddress() {
		return mynode.getAddress();
	}	
	
	public void addNode(InetSocketAddress address) {
		kbroker.PingRequest(address, null);
	}
	
	
	public void bootstrap(InetSocketAddress address) {
		kbroker.NodeRequest(address, mynode.getId(), new ResponseHandler() {

			public void onResponse(IncomingMessage response) {

				List<Node> nodes = KBroker.parseNodeList(response.getPayload());
				new NodeOperation(nodes, mynode.getId(), reactor, new Callback() {

					public void onFinished() {
						/* Get furthest node we know of */
						List<Node> nodes = table.findNodes(mynode.getId().getInverse(), 1);
						Node n = nodes.get(0);
						int prefixmatch = n.getId().getMatchingPrefix(mynode.getId());
						//TODO: shouldn't this set 0..prefixmatch insteadof prefixmatch..id.size()
						for(int i = prefixmatch; i < mynode.getId().size(); i++) {
							bucketfreshness[i] = 1;
						}
						// start bucket refresher
						reactor.addTask(new BucketRefresher(), System.currentTimeMillis() + bucketcheckinterval + rand.nextInt(4000) - 2000, false);
					}

					public void onResponse(Object response) {
						// ignore
					}
					
				}, kbroker).go();				
			}

			@Override
			public void onTimeout() {
				// ignore
				
			}
		});
	}
	
	
	public KeyPair getKeys() {
		return mykeys;
	}
	
	
	private void generateKeys() {
		
		KeyPairGenerator gen = null;		
		try {
			gen = KeyPairGenerator.getInstance(keyalg);
			gen.initialize(signkeybits);
			mykeys = gen.generateKeyPair();
		} catch (NoSuchAlgorithmException e) {
			assert(false);
		}
	}
	
	
	public void get(byte[] key, Callback callback) {
		Id target = keyToId(key);
		ValueOperation getop = new ValueOperation(key, kbroker, table.findNodes(target, Kademlia.k), callback, reactor);
		getop.go();
	}
	
	
	public void set(byte[] key, byte[] value, Callback callback) {
		Value v = new Value(key, value);
		Id target = keyToId(key);
		try {
			v.sign(mykeys);
		} catch (Exception e) {
			assert(false);
		}
		
		findNodes(target, new StoreValue(v, callback));		
	}
	
	
	void findNodes(Id target, Callback callback) {
		
		NodeOperation findop = new NodeOperation(table.findNodes(target, Kademlia.k), target, reactor, callback, kbroker);
		bucketfreshness[target.getMatchingPrefix(mynode.getId())] = System.currentTimeMillis();
		findop.go();
	}
	
	
	public void dumpState(OutputStream out) throws IOException {
		ObjectOutputStream objout = new ObjectOutputStream(out);
		objout.writeObject(mykeys);
		objout.writeObject(table);
	}
	
	public void loadState(InputStream in) throws StreamCorruptedException, IOException, ClassNotFoundException {
		ObjectInputStream objin = new ObjectInputStream(in);
		mykeys = (KeyPair) objin.readObject();
		table = (RoutingTable) objin.readObject();
	}
	
	public void writeTable() {
		table.writeTable();
	}
	
	public static Id pubkeyToId(PublicKey pubkey) {
		return keyToId(pubkey.getEncoded());
	}
	
	public static Id keyToId(byte[] key) {

		try {
			MessageDigest md = MessageDigest.getInstance(mdalg);
			md.update(key);
			Id id = new Id(md.digest());
			return id;
		} catch (NoSuchAlgorithmException e) {
			e.printStackTrace();
			assert(false);
			return null;
		}
	}
		
	public static PublicKey enckeyToPublicKey(byte[] enckey) throws InvalidKeySpecException {
		X509EncodedKeySpec keyspec = new X509EncodedKeySpec(enckey);
		try {
			KeyFactory keyfactory = KeyFactory.getInstance(keyalg);
			return keyfactory.generatePublic(keyspec);
			
		} catch (NoSuchAlgorithmException e) {
			assert(false);
			return null;
		}
	}
	
	private class BucketRefresher implements Runnable {
		
		public void run() {
			try {
				long freshness = Long.MAX_VALUE;
				for(int i = 0, j = curbucket; i < mynode.getId().size(); i++, j = (j + 1) % mynode.getId().size()) {
					if(bucketfreshness[j] < freshness) {
						curbucket = j;
						freshness = bucketfreshness[j];
					}
				}

				if(freshness + freshbucket < System.currentTimeMillis()) {
					System.out.println("Refreshing bucket " + curbucket);
					findNodes(Id.getRandomId(mynode.getId(), curbucket, true), null);
				}
			} catch (Exception e) {
				e.printStackTrace();
				assert(false);
			}
			
			reactor.addTask(this, System.currentTimeMillis() + bucketcheckinterval + rand.nextInt(4000) - 2000, false);
		}
		
		private int curbucket = 0;
	}
	
	private class StoreValue implements Callback {

		public StoreValue(Value v, Callback cb) {
			this.value = v;
			this.callback = cb;
		}
		
		public void onFinished() {
			new StoreOperation(nodes, value, reactor, callback, kbroker).go();
			
		}

		@SuppressWarnings("unchecked")
		public void onResponse(Object response) {
			this.nodes = (List<Node>) response;
		}
		
		private Value value;
		private List<Node> nodes;
		private Callback callback;
		
	}
	
	private KeyPair mykeys;
	private final Node mynode;
	private final NetReactor reactor;
	private final KBroker kbroker;
	private RoutingTable table;
	private final ValueStore store;
	private final long[] bucketfreshness;
	private final long bucketcheckinterval;
	private final Random rand = new Random();
	
	/* Kademlia constants */	
	static final int k = 2; // replication parameter
	static final int a = 3; // concurrency parameter
	static final String keyalg = "RSA";
	static final String signalg = "SHA1WITHRSA";
	static final int signkeybits = 2048;
	static final String mdalg = "SHA-1";
	static final String strenc = "utf-8";
	static final long freshbucket = 3600000; /* half an hour */
	static final long republish = 3600000; /* one hour */
	static final long expiration = 86400000; /* 24 hours */
}
